{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Dashboard to view and filter your Running Activities Laps by Distance And Pace"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import datetime\n",
    "from ipywidgets import fixed, Layout, interactive\n",
    "from garmindb import GarminConnectConfigManager\n",
    "from garmindb.garmindb import GarminDb, Attributes, ActivitiesDb, Activities, ActivityLaps, ActivityRecords\n",
    "from maps import ActivityMap\n",
    "from collections import ChainMap\n",
    "import fitfile\n",
    "from fitfile import Distance\n",
    "import pandas as pd\n",
    "import datetime\n",
    "import math"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Variables that can be Customized"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "group_activities_by_name = [\"2000\", \"1000\", \"400\", \"200\", \"Tempo Run\", \"Recovery Run\", \"Without Name Match\"]\n",
    "group_activities_by_lap_distance = [1000, 800, 600, 400, 200]\n",
    "group_activities_by_lap_speed = datetime.time(0, 5, 0)\n",
    "lap_distance_precision = 50\n",
    "original_lap_filter_list = [\"None\"] + [1000, 800, 600, 400, 200]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "* __group_activities_by_name__: This List can be used to group activities by name... in my case I use custom workouts on the Garmin app and sync them to my watch to name to my activities like \"3x2000 or 20x200 or Tempo Run\"... can be left empty if you use the default garmin activity name.\n",
    "* __group_activities_by_lap_distance__ This list can be used to group activities with laps with the same distance and the pace below the next variable.\n",
    "* __group_activities_by_lap_speed__ This is the pace filter used to group activities by distance\n",
    "* __lap_distance_precision__ This is the distance precision to group, with the number 50 laps from 950 to 1050 will be counted as 1000 laps. Useful if you don't use custom workouts with predefined distances.\n",
    "* __original_lap_filter_list__ Lista to filter the laps from the current activity. This way you can select a training that you made and watch only the laps from X distance, or select None and watch all the laps."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Standard Variables"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "gc_config = GarminConnectConfigManager()\n",
    "db_params_dict = gc_config.get_db_params()\n",
    "garmin_db = GarminDb(db_params_dict)\n",
    "garmin_act_db = ActivitiesDb(db_params_dict)\n",
    "measurement_system = Attributes.measurements_type(garmin_db)\n",
    "unit_strings = fitfile.units.unit_strings[measurement_system]\n",
    "distance_units = unit_strings[fitfile.units.UnitTypes.distance_long]\n",
    "altitude_units = unit_strings[fitfile.units.UnitTypes.altitude]\n",
    "temp_units = unit_strings[fitfile.units.UnitTypes.tempurature]\n",
    "group_activities_by_lap_distance_converted = [Distance.from_meters_or_feet(distance).kms_or_miles() for distance in group_activities_by_lap_distance]\n",
    "activities_dict = {key: [] for key in group_activities_by_name + group_activities_by_lap_distance}\n",
    "current_selected_lap = \"None\"\n",
    "current_selected_pace = \"None\"\n",
    "Treino = ['All'] + list(activities_dict.keys())\n",
    "activities_list = []\n",
    "complete_laps_list = []\n",
    "custom_layout = Layout(width='max-content')     \n",
    "custom_style = {'description_width': '100px'}\n",
    "lap_distance_precision = Distance.from_meters_or_feet(lap_distance_precision).kms_or_miles()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Support Functions"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class CustomActivity(object):\n",
    "    \n",
    "    def __init__(self, id, name, date):\n",
    "        self.id = id\n",
    "        self.name = name\n",
    "        self.date = date\n",
    "    \n",
    "    def __repr__(self):\n",
    "        return f\"{self.date} - {self.name} - {self.id}\"\n",
    "\n",
    "def get_pace_list():\n",
    "    # Initialize the start and end times in seconds\n",
    "    start_time = 3 * 60  # 3 minutes in seconds\n",
    "    end_time = 7 * 60   # 10 minutes in seconds\n",
    "\n",
    "    # Create an empty list to store the formatted times\n",
    "    time_list = []\n",
    "\n",
    "    # Iterate from start_time to end_time in 1-second increments\n",
    "    for time_in_seconds in range(start_time, end_time + 1):\n",
    "        # Calculate minutes and seconds\n",
    "        minutes = time_in_seconds // 60\n",
    "        seconds = time_in_seconds % 60\n",
    "\n",
    "        # Format the time as a string and append to the list\n",
    "        time_str = f\"{minutes:02}:{seconds:02}\"\n",
    "        time_list.append(time_str)\n",
    "\n",
    "    # return the list of formatted times\n",
    "    return time_list[::10]\n",
    "\n",
    "def convert_to_time(pace_filter: str) -> datetime.time:\n",
    "    try:\n",
    "        time_list = pace_filter.split(\":\")\n",
    "        return datetime.time(0, int(time_list[0]), int(time_list[1]))\n",
    "    except:\n",
    "        return datetime.time.max\n",
    "\n",
    "def remove_duplicates_from_list(my_list):\n",
    "    return list(dict.fromkeys(my_list))\n",
    "\n",
    "def remove_duplicates(objects, key=lambda x: x):\n",
    "    seen = set()\n",
    "    unique_objects = []\n",
    "    for obj in objects:\n",
    "        obj_key = key(obj)\n",
    "        if obj_key not in seen:\n",
    "            seen.add(obj_key)\n",
    "            unique_objects.append(obj)\n",
    "    return unique_objects\n",
    "\n",
    "def round_up_miliseconds(my_time):\n",
    "    microseconds = my_time.microsecond\n",
    "    if microseconds > 0:\n",
    "        # Calculate the number of microseconds to round up to the nearest second\n",
    "        microseconds_to_round = 1000000 - microseconds\n",
    "\n",
    "        # Create a timedelta with the microseconds to round up\n",
    "        rounding_delta = datetime.timedelta(microseconds=microseconds_to_round)\n",
    "\n",
    "        # Add the rounding delta to the original time\n",
    "        my_time = (datetime.datetime.combine(datetime.date(1, 1, 1), my_time) + rounding_delta).time()\n",
    "\n",
    "    return my_time\n",
    "    # Format the time as a string\n",
    "\n",
    "def time_dif_in_seconds(time_1: datetime.time, time_2: datetime.time) -> int:\n",
    "    if time_1 is not None and time_2 is not None:\n",
    "        return fitfile.conversions.time_to_secs(time_2) - fitfile.conversions.time_to_secs(time_1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Main Functions"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_laps_df(activity_obj: CustomActivity, laps_filter=\"None\", pace_filter=\"None\"):\n",
    "    laps = ActivityLaps.get_activity(garmin_act_db, activity_obj.id)\n",
    "    if len(laps) == 0:\n",
    "        return f\"ActivityLaps object not found in the database for the activity with id {activity_obj.id}\"\n",
    "    custom_main_laps, original_main_laps = extract_main_laps(laps, laps_filter=laps_filter, pace_filter=pace_filter)\n",
    "    if len(custom_main_laps) == 0:\n",
    "        return f\"No Laps Found With Current Filters\"\n",
    "    laps_df = pd.DataFrame(custom_main_laps)\n",
    "    total_laps_time = fitfile.conversions.timedelta_to_time(laps_df[\"time\"].apply(lambda x: fitfile.conversions.time_to_timedelta(x)).sum())\n",
    "    total_laps_distance = laps_df[\"distance\"].sum()\n",
    "    total_laps_in_hour = fitfile.conversions.time_to_secs(total_laps_time) / 3600\n",
    "    laps_df[\"avg_pace\"] = fitfile.conversions.perhour_speed_to_pace(total_laps_distance / total_laps_in_hour)\n",
    "    laps_df[\"dif_pace\"] = laps_df.apply(lambda x: time_dif_in_seconds(x[\"avg_pace\"],x[\"pace\"]), axis=1)\n",
    "    map = None\n",
    "    if len(original_main_laps) and original_main_laps[0].start_lat is not None:\n",
    "        records = ActivityRecords.get_activity(garmin_act_db, activity_obj.id)\n",
    "        if len(records) and records[-1].position_lat is not None:\n",
    "            map = ActivityMap(records, original_main_laps)\n",
    "    return laps_df, map\n",
    "\n",
    "def extract_main_laps(laps, laps_filter, pace_filter):\n",
    "    try:\n",
    "        original_main_laps = []\n",
    "        custom_main_laps = []\n",
    "        for lap in laps:\n",
    "            if (laps_filter == \"None\" or math.isclose(lap.distance, Distance.from_meters_or_feet(laps_filter).kms_or_miles(), rel_tol=lap_distance_precision)):\n",
    "                lap.pace = fitfile.conversions.perhour_speed_to_pace(lap.avg_speed)\n",
    "                if pace_filter == \"None\" or lap.pace < convert_to_time(pace_filter):\n",
    "                    custom_lap_dict = {\n",
    "                        \"lap\": lap.lap,\n",
    "                        \"distance\": lap.distance,\n",
    "                        \"time\": lap.moving_time,\n",
    "                        \"pace\": lap.pace,\n",
    "                        \"speed\": lap.avg_speed,\n",
    "                        \"avg_hr\": lap.avg_hr,\n",
    "                        \"max_hr\": lap.max_hr,\n",
    "                        \"ascent\": lap.ascent,\n",
    "                        \"descent\": lap.descent,\n",
    "                    }\n",
    "                    custom_main_laps.append(custom_lap_dict)\n",
    "                    original_main_laps.append(lap)\n",
    "        return custom_main_laps, original_main_laps\n",
    "    except Exception as ex:\n",
    "        if laps_filter != \"None\" or pace_filter != \"None\":\n",
    "            return extract_main_laps(laps, \"None\", \"None\")\n",
    "        else:\n",
    "            raise Exception(f\"Error on extracting main laps from activity: {ex}\")\n",
    "\n",
    "def load_activities_list():\n",
    "    global activities_list, activities_dict\n",
    "    activities_list = Activities.get_by_sport(garmin_act_db, \"running\")\n",
    "    activities_list.reverse()\n",
    "    for activity in activities_list:\n",
    "        if len(group_activities_by_name) > 0:\n",
    "            matches_by_name = [name for name in group_activities_by_name if name in activity.name]\n",
    "            if len(matches_by_name) > 0:\n",
    "                for key in matches_by_name:\n",
    "                    activities_dict[key].append(CustomActivity(activity.activity_id, activity.name, activity.start_time.date()))\n",
    "            else:\n",
    "                key = \"Without Name Match\"\n",
    "                activities_dict[key].append(CustomActivity(activity.activity_id, activity.name, activity.start_time.date()))\n",
    "        if len(group_activities_by_lap_distance) > 0:\n",
    "            laps = ActivityLaps.get_activity(garmin_act_db, activity.activity_id)\n",
    "            complete_laps_list.extend(laps)\n",
    "            for lap in laps:\n",
    "                lap.avg_pace = fitfile.conversions.perhour_speed_to_pace(lap.avg_speed)\n",
    "            for key, lap_search_distance in zip(group_activities_by_lap_distance, group_activities_by_lap_distance_converted):\n",
    "                if any([lap for lap in laps if lap.distance is not None and math.isclose(lap.distance, lap_search_distance, rel_tol=lap_distance_precision) and lap.avg_pace is not None and lap.avg_pace < group_activities_by_lap_speed]):\n",
    "                    activities_dict[key].append(CustomActivity(activity.activity_id, activity.name, activity.start_time.date()))\n",
    "\n",
    "def format_df(original_df):\n",
    "    try:\n",
    "        df = original_df.copy()\n",
    "        df[\"time\"] = df[\"time\"].apply(lambda x: round_up_miliseconds(x).strftime(\"%M:%S\"))\n",
    "        df[\"pace\"] = df[\"pace\"].apply(lambda x: round_up_miliseconds(x).strftime(\"%M:%S\"))\n",
    "        df[\"avg_pace\"] = df[\"avg_pace\"].apply(lambda x: round_up_miliseconds(x).strftime(\"%M:%S\"))\n",
    "        df = df[[\"lap\", \"distance\", \"time\", \"pace\", \"avg_pace\", \"dif_pace\", \"speed\", \"avg_hr\", \"max_hr\", \"ascent\", \"descent\"]]\n",
    "        df = df.style.format(precision=1)\n",
    "        return df\n",
    "    except Exception as ex:\n",
    "        display(f\"Error formating the data - {ex}\")\n",
    "        return original_df"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Ipywidgets Control"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def select_training_filter(treino):\n",
    "    if treino == 'All':\n",
    "        tmp = list(ChainMap(*[activities_dict.get(key) for key in activities_dict.keys()]))\n",
    "        tmp = sorted(tmp, key=lambda x: x.date, reverse=True)\n",
    "        tmp = remove_duplicates(tmp, key=lambda obj: obj.id)\n",
    "        activity = tmp\n",
    "    else:\n",
    "        activity = activities_dict.get(treino)\n",
    "    activity_widget = interactive(select_activity, activity=activity)\n",
    "    activity_widget.children[0].description = \"Activity\"\n",
    "    activity_widget.children[0].style = custom_style\n",
    "    activity_widget.children[0].layout = custom_layout\n",
    "    display(activity_widget)\n",
    "\n",
    "def select_activity(activity):\n",
    "    global current_selected_lap\n",
    "    lap_filter_list = original_lap_filter_list.copy()\n",
    "    if current_selected_lap != \"None\":\n",
    "        lap_filter_list.insert(0, current_selected_lap)\n",
    "    lap_filter_list = remove_duplicates_from_list(lap_filter_list)\n",
    "    lap_filter_widget = interactive(select_lap_filter, lap_filter=lap_filter_list, activity=fixed(activity))\n",
    "    lap_filter_widget.children[0].description = \"Lap Filter\"\n",
    "    lap_filter_widget.children[0].style = custom_style\n",
    "    lap_filter_widget.children[0].layout = custom_layout\n",
    "    display(lap_filter_widget)\n",
    "\n",
    "def select_lap_filter(lap_filter, activity):\n",
    "    global current_selected_lap, current_selected_pace\n",
    "    current_selected_lap = lap_filter\n",
    "    pace_filter_list = original_pace_filter_list\n",
    "    if current_selected_pace != \"None\":\n",
    "        pace_filter_list.insert(0, current_selected_pace)\n",
    "    pace_filter_list = remove_duplicates_from_list(pace_filter_list)\n",
    "    pace_filter_widget = interactive(select_pace_filter, pace_filter=pace_filter_list, activity=fixed(activity), lap_filter=fixed(lap_filter))\n",
    "    pace_filter_widget.children[0].description = \"Pace Filter\"\n",
    "    pace_filter_widget.children[0].style = custom_style\n",
    "    pace_filter_widget.children[0].layout = custom_layout\n",
    "    display(pace_filter_widget)\n",
    "\n",
    "def select_pace_filter(pace_filter, activity, lap_filter):\n",
    "    global current_selected_pace\n",
    "    current_selected_pace = pace_filter\n",
    "    response, map = get_laps_df(activity, lap_filter, pace_filter)\n",
    "    if isinstance(response, pd.DataFrame):\n",
    "         display(format_df(response))\n",
    "    else:\n",
    "        display(response)\n",
    "    if map:\n",
    "        map.display()\n",
    "\n",
    "\n",
    "load_activities_list()\n",
    "original_pace_filter_list = [\"None\"] + get_pace_list()\n",
    "activities_filter_widget = interactive(select_training_filter, treino=Treino)\n",
    "activities_filter_widget.children[0].description = \"Activities Filter\"\n",
    "activities_filter_widget.children[0].style = custom_style\n",
    "activities_filter_widget.children[0].layout = custom_layout\n",
    "display(activities_filter_widget)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "env",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
